#!/usr/bin/env python3
"""Everything an agent puts in the notebook goes through this command. Each call has one append-only log,
$AUX_HOME/calls/<call-id>/notebook.jsonl, one JSON object per line; the app draws the notebook from it, top to
bottom in the order written.

  aux note "Refresh has no retry" "One GitHub 502 signs the user out."   # title, then the key fact
  aux note --alert "Refresh token is written to the logs" - <<'EOF'      # "-": the body comes from stdin
  auth/refresh.ts:52 logs the whole GitHub response, token included.
  EOF
  aux note "Token can expire mid-request" "Lasts 8 h." --see "GitHub docs=https://docs.github.com/…" --see "auth/refresh.ts:41"
  aux note "…" "…" --action '{"type":"copy","label":"Copy SQL","text":"select …"}'

  aux note "Refresh has no retry" "…" --action '{"type":"do","label":"Create a Linear issue for a follow-up PR","prompt":"Create a Linear issue in ENG titled …"}'

  aux options "Which Slack channel for feedback" - <<'EOF'   # the options the room is weighing; prints its id
  [{"name": "#aux-feedback", "pros": ["Easy to find"], "cons": ["One more channel"]}, {"name": "#ops"}]
  EOF
  aux options --id <id> "Which Slack channel for feedback" - <<'EOF'   # the same list, revised in place
  [{"name": "#aux-feedback", "pros": ["Easy to find"], "state": "chosen"}, {"name": "#ops", "state": "dropped"}]
  EOF
  aux topic "Reviewing PR #412"            # a section heading; the first one names what the call is about
  aux action <note-id> 0 done "Created ENG-2187: retry token refresh"   # an action session reports (running | done | failed)
  aux status "Checking whether user count is up since last month"   # the research under way; the app clears it
  aux call "Call with Mikey"               # the notebook's title
  aux show                                 # the notebook as Markdown, with the operator's thumbs
  aux replay fixtures/pr-review.jsonl [--speed 3] [--from 60]   # write a recorded log again, with its gaps

The call is --call, else $AUX_CALL, else the running call agent's ($AUX_HOME/agent.json). A note is also
prepended to wiki/notifications.md in the feed's format (--no-feed or AUX_NO_FEED=1 skips it), so the wiki has
it. The app no longer reads that file.

Events: {"at": <ms since 1970>, "type": …} plus
  note    id, title, body, alert, links [{title, url}], sources [text], actions [{type, label, …}],
          options [{name, pros [text], cons [text], state (open | chosen | dropped)}]
  revise  note, title, options; an options note rewritten in place (its votes and shares stay)
  topic   title
  action  note, index, state (running | done | failed), text
  vote    note, vote (up | down | none), reason, source (thumb | share | read_aloud | action | link); written by the app
  share   note, state (posted | failed), text; written by the app when the operator shares a note with the call
  status  text ("" when cleared)
  call    title
"""
import argparse
import json
import os
import re
import sys
import time
import uuid
from datetime import datetime
from pathlib import Path

# Inside an app bundle (Tuple.app/Contents/Helpers/Tuple Aux.app) a __pycache__ write is macOS's
# "python3 was prevented from modifying apps" prompt; the import never needs one.
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent))
import aux_context as ctx  # noqa: E402

MAX_TITLE = 60      # one line in the notebook (Ben, 2026-11-11: notes were far too long)
MAX_LEAD = 120      # the body's first line, all the notebook shows under the title
MAX_DETAIL = 400    # what unfolds; --long lifts it when the operator asked for the detail
MAX_ACTIONS = 2
MAX_OPTIONS = 6
MAX_OPTION = 40     # an option's name
MAX_POINTS = 3      # pros, and cons, per option
MAX_POINT = 60
MAX_LABEL = 60
# The vote sources that are the operator's own word.
EXPLICIT = ("thumb", "share")


def die(message):
    sys.exit(f"aux: {message}")


def now_ms():
    return int(time.time() * 1000)


def one_line(text):
    return " ".join(text.split())


# MARK: the log

def call_id(args):
    call = args.call or os.environ.get("AUX_CALL") or ctx.agent_record().get("call")
    if not call:
        die("no call: pass --call <id> (no call agent is recorded in agent.json)")
    return call


def log_path(call):
    return ctx.AUX / "calls" / call / "notebook.jsonl"


def append(call, event):
    """One line, one write: appends from the listening agent and an asks session never interleave."""
    path = log_path(call)
    path.parent.mkdir(parents=True, exist_ok=True)
    line = json.dumps({"at": now_ms(), **event}, ensure_ascii=False) + "\n"
    fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
    try:
        os.write(fd, line.encode())
    finally:
        os.close(fd)


def events(call):
    try:
        lines = log_path(call).read_text().splitlines()
    except OSError:
        return []
    out = []
    for line in lines:
        try:
            out.append(json.loads(line))
        except ValueError:
            continue    # a torn or hand-edited line is skipped, never fatal
    return out


def fold(evs):
    """The notebook as the app draws it: topics and notes in the order written. Each note carries its
    latest vote and the state of its actions. Returns (title, [entry])."""
    title, entries, notes = None, [], {}
    for e in evs:
        kind = e.get("type")
        if kind == "call":
            title = e.get("title")
        elif kind == "topic":
            entries.append(e)
        elif kind == "note":
            e = dict(e)
            notes[e.get("id")] = e
            entries.append(e)
        elif kind == "vote" and e.get("note") in notes:
            n = notes[e["note"]]
            if e.get("vote") == "none":
                n.pop("vote", None)
                n.pop("reason", None)
            # A thumb or a share is the operator's word: nothing implicit replaces it.
            elif e.get("source") in EXPLICIT or n.get("vote_source") not in EXPLICIT:
                n["vote"], n["reason"], n["vote_source"] = e.get("vote"), e.get("reason") or "", e.get("source") or ""
        elif kind == "revise" and e.get("note") in notes:
            notes[e["note"]].update(title=e.get("title") or notes[e["note"]]["title"], options=e.get("options") or [])
        elif kind == "action" and e.get("note") in notes:
            notes[e["note"]].setdefault("action_states", {})[e.get("index")] = (e.get("state"), e.get("text") or "")
        elif kind == "clear":
            for n in notes.values():
                n["deleted"] = True
        elif kind == "delete" and e.get("note") in notes:
            notes[e["note"]]["deleted"] = True
        elif kind == "share" and e.get("note") in notes:
            notes[e["note"]]["shared"] = e.get("state") == "posted" or notes[e["note"]].get("shared", False)
    return title, entries


def body_arg(value):
    """A body given as an argument, or "-" for stdin. Stdin is never read unasked: a command with no
    redirect would wait on it forever."""
    if value == "-":
        return sys.stdin.read().strip("\n")
    return (value or "").strip("\n")


# MARK: notes

def parse_see(items):
    links, sources = [], []
    for item in items:
        label, sep, url = item.partition("=")
        if sep and re.match(r"https?://\S+$", url.strip()) and label.strip():
            links.append({"title": one_line(label), "url": url.strip()})
        elif re.match(r"https?://\S+$", item.strip()):
            links.append({"title": re.sub(r"^https?://(www\.)?", "", item.strip()).split("/")[0], "url": item.strip()})
        else:
            sources.append(one_line(item))
    return links, sources


def parse_action(text):
    try:
        a = json.loads(text)
    except ValueError as e:
        die(f"--action is not JSON: {e}")
    if not isinstance(a, dict):
        die("--action takes one JSON object")
    label = one_line(str(a.get("label", "")))
    if not label or len(label) > MAX_LABEL:
        die(f"an action needs a label of at most {MAX_LABEL} characters")
    kind = a.get("type")
    if kind == "copy" and str(a.get("text", "")).strip():
        return {"type": "copy", "label": label, "text": a["text"]}
    if kind == "email" and str(a.get("subject", "")).strip() and str(a.get("body", "")).strip():
        return {"type": "email", "label": label, "to": a.get("to", ""), "subject": a["subject"], "body": a["body"]}
    if kind == "open" and re.match(r"https?://\S+$", str(a.get("url", ""))):
        return {"type": "open", "label": label, "url": a["url"]}
    if kind == "do" and str(a.get("prompt", "")).strip():
        return {"type": "do", "label": label, "prompt": a["prompt"]}
    die('an action is {"type":"copy","label","text"}, {"type":"email","label","subject","body"[,"to"]}, '
        '{"type":"open","label","url"} with an http(s) url, or {"type":"do","label","prompt"}')


def cmd_note(args):
    title = one_line(args.title)
    if not title:
        die("a note needs a title")
    if len(title) > MAX_TITLE:
        die(f"the title is {len(title)} characters; state the point in at most {MAX_TITLE} and put the rest in the body")
    body = body_arg(args.body)
    lead, _, detail = body.strip().partition("\n\n")
    if re.match(r"\s*([-*+|]|\d+[.)])\s", lead + " "):
        die("the body's first line is one sentence, the key fact; a list or table goes after a blank line, if anywhere")
    if len(lead) > MAX_LEAD:
        die(f"the body's first line is {len(lead)} characters; keep the key fact under {MAX_LEAD}, or cut it")
    if len(detail) > MAX_DETAIL and not args.long:
        die(f"the detail is {len(detail)} characters; keep it under {MAX_DETAIL}: the one table or number that matters, nothing else")
    if len(args.action) > MAX_ACTIONS:
        die(f"at most {MAX_ACTIONS} actions")
    links, sources = parse_see(args.see)
    note = {"type": "note", "id": str(uuid.uuid4()), "title": title, "body": body, "alert": args.alert,
            "links": links, "sources": sources, "actions": [parse_action(a) for a in args.action]}
    call = call_id(args)
    # The subject the note belongs to: when it isn't the heading the page is under, it becomes the next one.
    # Deciding it on every note is what keeps the headings with the talk (a rule to write topics "when the
    # subject changes" was read and not followed, 2026-11-11).
    topic = one_line(args.topic or "")
    if topic:
        last = next((e.get("title", "") for e in reversed(events(call)) if e.get("type") == "topic"), "")
        if same_subject(topic, last) is False:
            append(call, {"type": "topic", "title": topic})
    append(call, note)
    print(note["id"])


def parse_options(text):
    try:
        raw = json.loads(text)
    except ValueError as e:
        die(f"the options are JSON: a list of {{name, pros, cons, state}} ({e})")
    if not isinstance(raw, list) or not 2 <= len(raw) <= MAX_OPTIONS:
        die(f"between 2 and {MAX_OPTIONS} options: the ones the room is actually weighing")
    out = []
    for o in raw:
        if not isinstance(o, dict):
            die("each option is an object: {name, pros, cons, state}")
        name = one_line(str(o.get("name", "")))
        if not name or len(name) > MAX_OPTION:
            die(f"an option's name is 1-{MAX_OPTION} characters: {name!r}")
        state = o.get("state", "open")
        if state not in ("open", "chosen", "dropped"):
            die("an option's state is open, chosen or dropped")
        option = {"name": name, "pros": [], "cons": [], "state": state}
        for side in ("pros", "cons"):
            points = [one_line(str(x)) for x in o.get(side) or [] if one_line(str(x))]
            if len(points) > MAX_POINTS:
                die(f"at most {MAX_POINTS} {side} per option: the ones that decide it")
            for x in points:
                if len(x) > MAX_POINT:
                    die(f"a pro or con is at most {MAX_POINT} characters: {x!r}")
            option[side] = points
        out.append(option)
    return out


def cmd_options(args):
    title = one_line(args.title)
    if not title or len(title) > MAX_TITLE:
        die(f"an options list needs a title of at most {MAX_TITLE} characters: the decision being made")
    options = parse_options(body_arg(args.options))
    call = call_id(args)
    if args.id:
        if not any(e.get("type") == "note" and e.get("id") == args.id and e.get("options") for e in events(call)):
            die(f"no options list {args.id} on this call's page")
        append(call, {"type": "revise", "note": args.id, "title": title, "options": options})
        print(args.id)
        return
    note = {"type": "note", "id": str(uuid.uuid4()), "title": title, "body": "", "alert": False,
            "links": [], "sources": [], "actions": [], "options": options}
    topic = one_line(args.topic or "")
    if topic:
        last = next((e.get("title", "") for e in reversed(events(call)) if e.get("type") == "topic"), "")
        if same_subject(topic, last) is False:
            append(call, {"type": "topic", "title": topic})
    append(call, note)
    print(note["id"])


# MARK: topics, pages, status, the title

def same_subject(a, b):
    """Whether two headings name the same subject: equal once case, punctuation and spacing are set aside."""
    norm = lambda t: " ".join(re.sub(r"[^\w\s#]", " ", t.lower()).split())
    return norm(a) == norm(b)

def cmd_topic(args):
    title = one_line(args.title)
    if not title:
        die("a topic needs a title")
    append(call_id(args), {"type": "topic", "title": title})


def cmd_action(args):
    call = call_id(args)
    note = next((e for e in fold(events(call))[1] if e.get("type") == "note" and e.get("id") == args.note), None)
    if not note:
        die(f"no note {args.note} on this call")
    if not 0 <= args.index < len(note.get("actions") or []):
        die(f"note {args.note} has no action {args.index}")
    append(call, {"type": "action", "note": args.note, "index": args.index, "state": args.state, "text": one_line(args.text or "")})


def cmd_status(args):
    append(call_id(args), {"type": "status", "text": "" if args.clear else one_line(args.text or "")})


def cmd_call(args):
    append(call_id(args), {"type": "call", "title": one_line(args.title)})


def cmd_show(args):
    title, entries = fold(events(call_id(args)))
    out = [f"# {title}", ""] if title else []
    for e in entries:
        kind = e["type"]
        if kind == "topic":
            out += [f"## {e['title']}", ""]
        elif kind == "note":
            how = {"thumb": "clicked", "share": "shared with the call", "read_aloud": "read out to the call",
                   "action": "used its action", "link": "followed its link"}
            vote = {"up": f" [👍 {how.get(e.get('vote_source'), '')}".rstrip() + "]",
                    "down": f" [👎 {e.get('reason') or ''}".rstrip() + "]"}.get(e.get("vote"), "")
            used = "".join(f" [{state}: {(e.get('actions') or [{}] * (i + 1))[i].get('label', '')}{' → ' + text if text else ''}]"
                           for i, (state, text) in sorted((e.get("action_states") or {}).items()))
            # A shared note says so even when a later thumb replaced the 👍 the share gave it.
            shared = " [shared with the call]" if e.get("shared") and not (e.get("vote") == "up" and e.get("vote_source") == "share") else ""
            # A deleted note stays in the record: the operator taking it off the page says what it was worth.
            deleted = " [🗑 deleted]" if e.get("deleted") else ""
            out += [f"- {'! ' if e.get('alert') else ''}{e['title']}" + (f": {one_line(e['body'])}" if e.get("body") else "") + vote + shared + used + deleted]
            for o in e.get("options") or []:
                mark = {"chosen": " [chosen]", "dropped": " [dropped]"}.get(o.get("state"), "")
                out += [f"  - {o.get('name', '')}{mark}" + "".join(f"; + {x}" for x in o.get("pros") or []) + "".join(f"; − {x}" for x in o.get("cons") or [])]
    print("\n".join(out).strip("\n"))


# MARK: replay

def cmd_replay(args):
    """Writes a recorded log again as a new call's, each event after the gap it had (divided by --speed).
    Events before --from seconds are written at once. Points $AUX_HOME/calls/current at the call, which is
    what the app shows when Tuple has no call on."""
    try:
        source = [json.loads(l) for l in Path(args.log).read_text().splitlines() if l.strip()]
    except (OSError, ValueError) as e:
        die(f"can't read {args.log}: {e}")
    if not source:
        die(f"{args.log} has no events")
    call = args.call or os.environ.get("AUX_CALL") or f"replay-{datetime.now().strftime('%H%M%S')}"
    path = log_path(call)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("")
    (ctx.AUX / "calls" / "current").write_text(call + "\n")
    start = source[0].get("at", 0)
    played = args.start * 1000
    for e in source:
        offset = e.get("at", start) - start
        if offset > played:
            time.sleep((offset - played) / 1000 / args.speed)
            played = offset
        append(call, {k: v for k, v in e.items() if k != "at"})
    print(call)


def main():
    ap = argparse.ArgumentParser(prog="aux", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--call", help="the Tuple call id (default: $AUX_CALL, else the running call agent's)")
    sub = ap.add_subparsers(dest="command", required=True)

    def command(name, run, **kw):
        """A subcommand that also takes --call after its name (SUPPRESS keeps one given before it)."""
        p = sub.add_parser(name, **kw)
        p.add_argument("--call", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
        p.set_defaults(run=run)
        return p

    p = command("note", cmd_note, help="write a note")
    p.add_argument("title")
    p.add_argument("body", nargs="?", default="", help='the key fact, then detail; "-" reads stdin')
    p.add_argument("--alert", action="store_true", help="a serious issue (rare)")
    p.add_argument("--see", action="append", default=[], metavar="LABEL=URL|SOURCE", help="a link, or where a claim comes from")
    p.add_argument("--action", action="append", default=[], metavar="JSON", help="a one-click action (at most two)")
    p.add_argument("--long", action="store_true", help="the operator asked for the detail: no cap on what unfolds")
    p.add_argument("--topic", metavar="SUBJECT", help="what the talk is about now; a new heading when it isn't the current one")

    p = command("options", cmd_options, help="the options the room is weighing, kept up to date")
    p.add_argument("title", help="the decision being made")
    p.add_argument("options", help='"-": a JSON list of {name, pros, cons, state} on stdin')
    p.add_argument("--id", help="revise this options list in place instead of writing a new one")
    p.add_argument("--topic", metavar="SUBJECT", help="what the talk is about now; a new heading when it isn't the current one")

    p = command("topic", cmd_topic, help="start a section")
    p.add_argument("title")

    p = command("action", cmd_action, help="report on an action being carried out")
    p.add_argument("note")
    p.add_argument("index", type=int)
    p.add_argument("state", choices=["running", "done", "failed"])
    p.add_argument("text", nargs="?", help="what was done, in one line")

    p = command("status", cmd_status, help="the header's status line")
    p.add_argument("text", nargs="?")
    p.add_argument("--clear", action="store_true")

    p = command("call", cmd_call, help="the notebook's title")
    p.add_argument("title")

    p = command("show", cmd_show, help="print the notebook")

    p = command("replay", cmd_replay, help="write a recorded log again")
    p.add_argument("log")
    p.add_argument("--speed", type=float, default=1.0)
    p.add_argument("--from", dest="start", type=float, default=0.0, metavar="SECONDS")

    args = ap.parse_args()
    args.run(args)


if __name__ == "__main__":
    main()
